单元测试 - 最佳实践
是不是最佳不知道,反正它是个实践!
预备
测试目标
每记后端是没有单元测试的,此次对其进行添加单元测试的重构。
技术选型及项目配置
主要技术如下
- 测试平台:JUnit5
- mock库:MockK
- 断言库:AssertK
- 测试覆盖率:Jacoco
- 数据层测试:embedded-database-spring-test、flyway-spring-test、mybatis-plus-spring-boot-test
gradle配置如下
1 | plugins { |
日志:src/test/resources下添加logback-test.xml文件,这里我们仅在控制台打印WARN级别的应用日志
1 |
|
IDEA:安装JUnit插件(如果是IDEA专业版,默认已安装)。
测试思路 - MVC如何测试
被测项目是采用Spring Web MVC构建的Restful服务,那么MVC应该如何测试呢?
如果我们将每一层的职责区分得比较好,则每层负责的内容和测试思路如下
- Controller:路由转发、参数验证
- 重点在参数验证的测试,对Service的调用应该被Mock掉
- 使用MockMvc
- 使用@WebMvcTest加载单个Controller类,提升启动速度
- Service:业务逻辑的处理
- 常规测试,单元测试的主要工作集中在这里
- 不使用Spring Test的任何注解,可以提升启动速度
- Repository:数据库访问
- 使用嵌入式数据库提供真实等价的数据库环境
- 使用@MybatisPlusTest只加载数据库相关的Bean,提升启动速度
你写的是单元测试还是集成测试?
使用Spring Boot Test,我们写出来的往往是集成测试。即一个测试中其实包含了Controller、Service、Repository。但真的单元测试,一个测试应该只包含一个方法的一种case。
集成测试的好处是写起来方便快捷,缺点是覆盖不全面,且一旦一个调用链路的任何环节修改,相关的case都必须修改。
接下来看具体的实施情况。
Controller测试
测什么
- 测试接口路由是否正确
- 测试参数验证是否符合预期
一个需求
一个典型待测试的接口如下
1 |
|
@Authenticate表示该接口需要鉴权,具体鉴权逻辑如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class AuthInterceptor(
val objectMapper: ObjectMapper
) : HandlerInterceptor {
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
// 从头部取X-5E-USER字段值,解析成User对象
val user = request.getHeader("X-5E-USER")?.let { objectMapper.readValue(it, User::class.java) }
// 判断方法是否被@Authenticate注解,如果被注解了且user为空,则报401错误
val authorize = (handler as HandlerMethod).beanType.getDeclaredAnnotation(Authenticate::class.java)
?: handler.getMethodAnnotation(Authenticate::class.java)
if (authorize != null && user == null) {
throw ResponseException(ResErrCode.NEED_AUTHORIZE)
}
return true
}
}UserUpdateReq需要被验证
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class UserUpdateReq {
var name: String? = null
var avatar: String? = null
var description: String? = null
}
测试类的构建
测试类的构建,主要考虑以下几个因素
只加载Web配置和待测Controller,其它Bean都不要,我们基于@WebMvcTest构建自己的注解
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
(ElementType.TYPE)
(RetentionPolicy.RUNTIME)
// WebMvcConfig是本业务中自定义的Web配置
(WebMvcConfig.class)
public ControllerTest {
(annotation = WebMvcTest.class)
String[] properties() default {};
(annotation = WebMvcTest.class)
Class<?>[] value() default {};
(annotation = WebMvcTest.class)
Class<?>[] controllers() default {};
(annotation = WebMvcTest.class)
boolean useDefaultFilters() default true;
(annotation = WebMvcTest.class)
Filter[] includeFilters() default {};
(annotation = WebMvcTest.class)
Filter[] excludeFilters() default {};
(annotation = WebMvcTest.class)
Class<?>[] excludeAutoConfiguration() default {};
}一个Controller一个测试类,但每个接口都有好几个case,因此需要分组。用内部类+@Nested实现,不过这样的话,一个内部类对应一个接口,可以定义一个接口,管理公共属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27interface IRequestMapping {
/**
* 指定本次测试的路径
*/
val path: String
/**
* 针对该路径的mock。这样,子类只需要调用mockRequest即可请求,而不需要每个case重新构建填写path、method等重复参数
*/
fun mockRequest(block: MockHttpServletRequestDsl.() -> Unit = {}): ResultActionsDsl
fun mockRequestWithToken(block: MockHttpServletRequestDsl.() -> Unit = {}): ResultActionsDsl {
return mockRequest {
header(mockAuthHeader.first, mockAuthHeader.second)
block(this)
}
}
fun mockRequestWithEmptyContent(block: MockHttpServletRequestDsl.() -> Unit = {}): ResultActionsDsl {
return mockRequest {
content = "{}"
block(this)
}
}
}实际上,所有的接口都有两个共同的需求,我们将其写成父类
都需要测试鉴权
1
2
3
4
5
6
7
8
9
10interface AuthenticateTest : IRequestMapping {
fun `401 when missing token`() {
mockRequestWithEmptyContent().andExpect {
status {
isUnauthorized()
}
}
}
}都需要验证响应结构
1
2
3
4
5
6
7
8
9interface ResponseFormatTest : IRequestMapping {
fun `response format validate`() {
val mockResult = mockRequestWithEmptyContent().andReturn()
assertThat(mockResult.response.contentType).isEqualTo(MediaType.APPLICATION_JSON.toString())
val resNode = publicObjectMapper.readTree(mockResult.response.contentAsString)
assertThat(publicObjectMapper.convertValue(resNode, R::class.java)).isInstanceOf(R::class)
}
}
这样,就构建得到如下测试类
1 |
|
关于Json格式的测试
当前项目一个比较特殊的需求:资源保存接口,资源的结构如下,每次保存时需要验证资源的结构是否符合预期。
1 | { |
其中的data结构不固定,多达五六种。服务端将data的验证逻辑写成了json schema,以自定义validator的形式加入Spring中。现在要对这些验证逻辑进行单元测试。
这其中的测试难点及解决方法如下
测试数据量大
将测试数据写在文件中,通过文件读取各条测试数据,再以参数化形式传入测试case。需要用到JUnit的@ParameterizedTest + @MethodSource达成
测试case多
我们提取了最常见的case,对它们进行数据构建
预期成功的case
- 拥有最少合法元素
- 拥有最多合法元素
- 可空元素都为null
以tag为例,可以看到除了数据之外我们还添加了针对该数据的说明:_comment_字段,这在测试结果中会打印出来
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47[
{
"_comment_": "拥有最少合法元素",
"resourceId": "12345678901234567890123456789012",
"resourceType": "tag",
"usn": 1,
"data": {
"id": "12345678901234567890123456789012",
"title": "5qCH562+5qCH6aKY",
"created_time": 1,
"updated_time": 1,
"version": 3
}
},
{
"_comment_": "拥有最多合法元素",
"resourceId": "12345678901234567890123456789012",
"resourceType": "tag",
"usn": 1,
"data": {
"id": "12345678901234567890123456789012",
"title": "5qCH562+5qCH6aKY",
"description": "5o+P6L+w",
"created_time": 1,
"updated_time": 1,
"deleted_time": 1,
"version": 3,
"usn": 1
}
},
{
"_comment_": "可空元素都为null",
"resourceId": "12345678901234567890123456789012",
"resourceType": "tag",
"usn": null,
"data": {
"id": "12345678901234567890123456789012",
"title": "5qCH562+5qCH6aKY",
"description": null,
"created_time": 1,
"updated_time": 1,
"deleted_time": null,
"version": 3,
"usn": null
}
}
]预期失败的case
- 必须字段未出现
- 不可空元素设置为null
- 数据类型问题:故意将每个元素的数据类型写错
- 数据格式问题:故意将每个元素的数据格式写错
- 出现未定义的字段
同样以tag为例,不同的是这次还多了_errorKeywords_字段,指出了该case的错误信息必须包含的关键字
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107[
{
"_comment_": "必须字段未出现",
"_errorKeywords_": [
"id",
"title",
"created_time",
"updated_time",
"version"
],
"resourceId": "12345678901234567890123456789012",
"resourceType": "tag",
"usn": 1,
"data": {}
},
{
"_comment_": "字段可空问题: id, title, created_time, updated_time, version",
"_errorKeywords_": [
"id",
"title",
"created_time",
"updated_time",
"version"
],
"resourceId": "12345678901234567890123456789012",
"resourceType": "tag",
"usn": 1,
"data": {
"id": null,
"title": null,
"description": "5o+P6L+w",
"created_time": null,
"updated_time": null,
"deleted_time": 1,
"version": null,
"usn": 1
}
},
{
"_comment_": "数据类型问题: id、title、description为string;其它为数字",
"_errorKeywords_": [
"id",
"title",
"description",
"created_time",
"updated_time",
"deleted_time",
"version",
"usn"
],
"resourceId": "12345678901234567890123456789012",
"resourceType": "tag",
"usn": 1,
"data": {
"id": 1,
"title": 1,
"description": 1,
"created_time": "1",
"updated_time": "1",
"deleted_time": "1",
"version": "3",
"usn": "1"
}
},
{
"_comment_": "数据格式问题: id为32位;title、description为base64;version固定为3",
"_errorKeywords_": [
"id",
"title",
"description",
"version"
],
"resourceId": "12345678901234567890123456789012",
"resourceType": "tag",
"usn": 1,
"data": {
"id": "123456789012345678901234567890",
"title": "明文标题",
"description": "明文描述",
"created_time": 1,
"updated_time": 1,
"deleted_time": 1,
"version": 2,
"usn": 1
}
},
{
"_comment_": "出现未定义字段: undefinedField",
"_errorKeywords_": [
"undefinedField"
],
"resourceId": "12345678901234567890123456789012",
"resourceType": "tag",
"usn": 1,
"data": {
"id": "12345678901234567890123456789012",
"title": "5qCH562+5qCH6aKY",
"description": "5o+P6L+w",
"created_time": 1,
"updated_time": 1,
"deleted_time": 1,
"version": 3,
"usn": 1,
"undefinedField": ""
}
}
]
目前为止所有数据有这么多:
至于测试方法,我们这样写
1 |
|
测试数据读取方法如下
1 | object ResourceProvider { |
如果运行测试,能够得到如下输出
可以改进的地方
- controller层测试还可以再抽象:不需要写代码,通过excel或db录入访问路径、预期输入、预期输出,直接生成测试代码生成,更为方便
- controller层测试或许可以和集成测试合并。但也不尽然,集成测试不一定要在controller这一次层做,以service为入口或许更加方便
Service测试
测什么
- 测试业务逻辑
一个需求
1 |
|
构建测试类
在Controller测试时,我们采用了@Nested+内部类的方式对接口进行了分组。但是在Service中,往往有很多个方法,每个方法的case也会非常多,如果还采用和Controller一样的方式,测试类会爆炸。因此我们采用类继承的方式进行分组。
父类如下:定义了mock对象和测试对象,以及测试对象中的共享mock属性
1 |
|
针对需求中的方法的测试类如下,只需要写测试方法即可
1 |
|
如何脱离Spring
当Service依赖Bean的注入使用构造器注入时,对Service层的单元测试就能脱离Spring:因为MockK在构建测试对象时,是通过构造器注入的。否则就得一个个手动mock
让代码更易于测试
最易于测试的代码当然是纯函数式,一个确定的输入就能对应一个确定的输出。但理想与现实是有差别的,日常业务中过分追求函数式可能会使得代码晦涩难懂。尽量函数式+mock才是正解。但是副作用和函数内部的级联调用过多,又会造成mock灾难。所以还是一个权衡的过程。
按照个人经验总结一下就是
- 函数式+适当mock
- 功能拆解:复杂方法拆分成若干小的方法,分别测试,这样弹性更高
一个例子:如下是重构之前的一个方法(获取用户邀请信息),如此之大,很显然是无法测试的
1 | fun getInvitationInfo(): InvitationInfoResponseDto { |
如下是重构之后的代码,按功能拆分,不但更易测,可读性也增强了不少。之前的代码不一点点看你一定不知道它在干啥
1 | fun getInvitationInfo(): InvitationInfoResponseDto { |
一个经验:在重构一个大方法时,预想拆分成多个子方法,每个方法写成完全无副作用、无级联方法调用,这样做的结果是子方法包含很多高阶方法,可读性相较于前,差了不少。
不要过分纠结BDD风格
BDD风格即所谓的 given - when - then 结构(mock数据 - 执行被测方法 - 断言判断),AssertJ提供BDD风格的断言。但given阶段由Mock库决定,then阶段由断言库决定。这两个库通常还不是一个,所以API很可能组合不出预想的效果。还不如老老实实用every{}进行mock,assertThat()进行断言,约定俗成,言简意赅,大家都懂。
Repository测试
对该层的测试难点及解决方法如下
数据库环境难以搭建
对于MySQL,还有嵌入式数据库H2可供选择。但对于PostgreSQL,有一个更好的选择:embedded-postres,测试期间启动数据库,支持PG11版本以上,支持run in docker。这是真实的环境,应该说是绝佳选择了。
MyBatis Plus写的如何测试
MyBatsi Plus有提供测试支持,仅加载数据库相关内容。
如下自定义的注解,包含了所有测试必须的内容
1 |
|
测什么
- 测试表完整性
- 测试SQL正确性
关于Flyway
flyway的使用,自行查阅手册,在这里的作用是:embedded-postres在启动本地数据库后,将使用flyway对数据库初始化。所以,src/db/migration下的sql必须组成一个完整的数据表环境。
表完整性测试
包括如下内容
- 不可空字段检查
- 可空或具有默认值的字段检查
- 唯一索引检查
由于每个表都需要进行完整性测试,因此我们写成一个抽象类。子类需要提供预期不允许为空的字段列表、具有默认值字段和默认值映射、唯一索引和用于测试它们的数据。
1 | abstract class RepositoryIntegrityTest<M : BaseMapper<T>, T> { |
以表senior_user为例,实现它如下
1 |
|
SQL正确性测试
通常的做法是:构建mock数据,插入数据库 -> 调用被测SQL -> 判断结果
1 |
|
被测SQL如下(当然它也不一定是个SQL,也可能是基于底层库构建的对数据库的操作,比如这里)
1 | fun updateUserInfo(id: Int, request: UserUpdateReq): Boolean { |
MyBatis Plus的SQL写在哪里
MyBatis Plus提供了类public class ServiceImpl<M extends BaseMapper<T>, T> implements IService<T>
,提供三个能力
- 快捷方法,如save、list、getById等
- 提供写SQL的DSL能力
- 提供baseMapper,对mapper直接调用
如此方便,以至于很多业务将Service类继承了它,省去了定义DAO的麻烦。个人认为,这样做在能力1和能力3的使用上都没有问题,但利用DSL构建SQL的能力对Service层造成了入侵,至少它不是容易测试的代码:
- 如果构建的SQL DSL和业务代码混在一起,则根本无法测试
- 如果将其提成一个单独的方法,那为什么不专门定义一个DAO来负责呢?能力1和能力3调用的方便性没有变差,能力2也得到了的有效管控
所以我的看法是:应该将ServiceImpl锁定在DAO里,不要侵入到业务代码。
测试锁
如下SQL如何测试,测试重点在于FOR UPDATE
1 | fun lockUser(userId: Int) { |
分析难点及解决方案
需要在两个并发的事物中分别调用才能测试
可以利用线程+手动管理事务+延迟
如何验证是否成功
使用
SELECT XXXX FOR UPDATE NOWAIT
语句,检测到锁冲突时马上报错
于是代码可以如下
1 | // 事务管理器,直接注入即可 |
问题记录
使用@WebMvcTest单独测试Controller时,报错:无法找到xxxMapper
原因:默认情况下,@WebMvcTest会将SpringBootApplication注解的那个启动类当做配置主类,而之前将@MapperScan写在了上面。
解决:将@MapperScan转移到单独的配置文件中。同时也提醒:配置不要乱放
logback-test.xml中指定的日志等级不生效
原因:如果在src/main/resources/application.properties中指定了logging.root.level,在配置文件中的等级就不会生效
解决:移除logging.root.level
要彻底解决,可以参考这篇文章
配置问题:Controller测试过程中发现一些针对Web的配置未生效
原因:配置类未被@WebMvcTest加载
解决:手动导入自定义的Web配置
Kotlin兼容性问题:@NotBlank对写在构造方法中的Kotlin属性不生效
原因:验证库和Kotlin的兼容性问题
解决:使用@field:NotBlank或者将属性从构造方法移到类体
MockK无法调用、mock私有方法
解决:MockK提供这样的功能,但使用起来不是很方便。所以这个问题尚未有较好的解决方式
总结
我花了两周去了解单元测试常用技术,细读JUnit用户手册,浏览了Spring Boot Test手册(但是忘记看Spring Test手册,是说用的时候觉得哪里不对,😓),去了解了如何正确地写单元测试。又花了两周对每记后端项目进行TDD重构,先后尝试了Mockito、AssertJ和MockK、AssertK,发现用Kotlin时,MockK比Mockito香得不是一点点(Mockito写得像Java,mock静态方法时还得打补丁,对Kotlin DSL的补丁看起来并不特别好;MockK就不一样了,功能全面,使用优雅)。
在补单元测试时,还修复了一些之前没注意也没测出来的bug,所谓矫枉过正,现在的我觉得没有单元测试的代码,是非常不可靠的。
本周开发新功能时,同步添加单元测试。两点感受颇深
- 写起来确实放心不少。写测试的过程中,已经细细品过很多边缘case了。也省去了本地启动服务一遍遍手动测试的麻烦。
- 是真的挺花费时间。扪心自问,如果进度要求再急一点,这单元测试可能写不下去,还得后面补。所以,要给自己预留充足的时间。
回看刚写的这些单测,还是有很多问题的
- case数量多,当前有将近300个case(算上条件测试)。根据80%的bug出在20%代码中的定律,可以依靠经验识别容易出问题的地方,集中测试,其它地方,相对可以放松警惕。这样能够节省写单测时间
- 测试速度慢,CI机上跑完所有case需要2:30左右。速度有待优化
- 测试代码质量有待加强
而就整个测试工作来说,要做的也还有很多
- 集成测试:目前没有集成测试,所以各层的协作还是靠手动测试
- CI+接口测试自动化:当前CI负责从构建、单元测试、发布应用的过程;还可以更加自动化:发布使用金丝雀,检测应用发布完成后对所有接口进行冒烟测试,测试通过再替换实际应用,否则就只发布金丝雀,从而最大程度降低发布风险。
- 推广单元测试:单测值得推广吗?肯定是值得的。但说实话,让我去写table应用的单元测试,我也会心生抗拒,毕竟case太多了。如果克服这些问题,让大家认真写测试,是个课题。